# h1=. paths.py

# p=. *Path Transformations* 
# _Jos'h.Fuller@starz.com 2009-07-09_

# h2. Front Matter

# h3. Imports

import sys, os, stat, re

# h3. Utility Functions

# Unfortunately, Unicode strings are different from regular
# strings. This function is just to conveniently discover if something
# is a string.

def stringp(data): return isinstance(data, basestring)

def pathp(data): return isinstance(data, path)

# This function removes duplicate path separators and breaks the path into chunks.

def _breakdown(path):
    "( path -- words) Conform path and decompose into words."

    path = re.sub("([/\\\\])+", "\\1", path) # Normalize slashes.
    return re.split("[/\\\\]", path)
    
# Only capitalizes a word if it's one letter long. Used to preserve
# Fusion path macros. Note that because I might get a path element
# that includes the ":", I need to check for single characters and
# single characters with colon.

def upsingle(word):
    if (1 == len(word)) or (                    # Unix spec.
        2 == len(word) and word.endswith(":")): # Windows.
        return word.upper()
    else: return word

# Convert a path to UNIX style:

def unix(path):
    "( path -- unix_path) Transform path to UNIX style."

    words = _breakdown(path)
    if not len(words[0]) and "cygdrive" == words[1]:
        words = ["",] + words[2:]
    if ":" in words[0]:
            words = ["", words[0][:-1].lower(),] + words[1:]
    return "/".join(words)

# Convert a path to the cygwin fake root UNIX style:

def cygwin(path):
    "( path -- cygwin_path) Transform path to fake cygwin UNIX style."

    words = _breakdown(path)
    if ":" in words[0]:
        words = ["", "cygdrive", words[0][:-1].lower(),] + words[1:]
    elif not len(words[0]) and "cygdrive" != words[1]:
        words = ["", "cygdrive",] + words[1:]
    return "/".join(words)

# Convert a path to Windows style, complete with backslashes:

def windows(path):
    "( path -- windows_path) Transform path to Windows-style."

    if not len(path): return ""
    
    words = _breakdown(path)
    if not len(words[0]):
        if "cygdrive" == words[1]: words = ["",] + words[2:]
        words = ["%s:" % upsingle(words[1]),] + words[2:]
    elif ":" in words[0]: words = [upsingle(words[0]),] + words[1:]
        
    return "\\".join(words)

# Converts a path into a UNIX-style slash format suitable for web
# browser external file references. I have to convert it to Windows
# format first to preserve the drive letter, if any.

def browser_file(path):
    "( path -- browser_file_path) Transform to format suitable for Firefox."

    return "/".join(_breakdown(windows(path)))

# Convert path into URL form, suitable for HTML page links:

def url(path):
    "( path -- url) Transform to URL."

    return "file:///" + re.sub(r"\\", "/", convert(path))

# In an ideal world, I would always be @convert@-ing paths to UNIX
# format. However, if this module is loaded on a Windows machine, I
# reluctantly switch @convert@ over to use the @windows@ function.

convert = unix
if sys.platform in ("win32 cygwin".split()):
    convert = windows

# Convert multiple arguments of any sort of valid path type to a
# single path. This is used for easy directory concatenation when making
# new path objects.

def collapse(*args):
    words = []

    for arg in args:
        if not arg: continue # None.
        
        t = type(arg).__name__
        if t in ("tuple", "list"): words.extend(arg)
        elif "path" == t: words.append(arg("x"))
        else: words.append(arg)

    return re.sub("[\\\\/]+", "/", ("/".join(words)).strip())

# h2. Path Object

# This object knows how to do most anything that you would want to do
# with a file path in a (relatively) safe, cross platform manner.
# Note that it can take either a path object, a tuple, list or string.

class path(object):

    def __init__(my, *args): my._where = apply(collapse, args)

# h3. Calling and Representation

# If you simply call a path instance you will get the path in the
# current system format. If you need something different, provide a
# hint:

# |_. Hint |_. Representation |_. Result | 
# | @"x"@ | UNIX |  @/n/this/is/a/test.txt@ | 
# | @"win"@ | Windows | @n:\\this\\is\\a\\test.txt@ | 
# | @"cygwin"@ | Cygwin | @/cygdrive/n/this/is/a/test.txt@ | 
# | @"browser"@ | Browser | @n:/this/is/a/test.txt@ | 
# | @"url"@ | Local URL | @file:///n/this/is/a/test.txt@ | 

    def __call__(my, hint=None):
        if not hint: hint = ""

        if   not my._where: return my._where # So "None" values don't fup.
        elif hint.endswith("x"): return unix(my._where)
        elif hint.startswith("win"): return windows(my._where)
        elif "cygwin" == hint: return cygwin(my._where)
        elif "browser" == hint: return browser_file(my._where)
        elif "url" == hint: return url(my._where)
        else: return convert(my._where)

# The special @__repr__()@ method (used by string formatting and the
# built-in @repr()@ function among other things) will call the object
# to get the most appropriate representation.

    def __repr__(my): return my()

# h3. Comparisons

# Because particular instances of an object can never be equal (unless
# they're both references to the same object), it would be nice to be
# able to compare two path objects based on their actual path
# data. The @__eq__()@ and @__ne__()@ override functions do this by
# comparing the breakdown word list for each path. 

# Using this "pure" representation should avoid platform dependencies
# with separators. All due respect to @collapse()@, but it doesn't
# guarantee that the paths are normalized. Also somebody could have
# reset the @_where@ property in the interim. 

# TODO If I started using the "pure" format internally, these
# comparisons wouldn't have to call the @words()@ method...

    def __eq__(my, other): 
        if stringp(other): other = path(other)
        return my.words() == other.words()

    def __ne__(my, other): return my.words() != other.words()

# h3. Path Transformations

# @words()@ converts path to a list of elements. Useful when you want
# to parse through a path without bothering with pesky separators.

    def words(my): 
        "( my -- [word, ]) Convert path to words."
        return _breakdown(my())

# @abspath()@ returns the absolute version of the current path.

    def abspath(my): 
        "( my -- path) Absolute path."
        return path(os.path.abspath(my()))

# Normalize the current path with @normpath()@, eliminating double
# slashes and other unsightly flab.

    def normpath(my): 
        "( my -- path) Normalize path (eliminate double slashes, etc.)"
        return path(os.path.normpath(my()))

# @parent()@ returns parent of current path as a another path object.
    
    def parent(my): 
        "( my -- parent) Returns parent directory."
        return path(my.dirname())

# h3. File Information

# If you need to quickly discover if a file is valid or what size it
# might be, these methods are for you. @exists()@ just checks to see
# that something answers on the filesystem to the current
# path. @isfile()@ and @isdir()@ checks to filesystem to see that a
# file or directory is actually present and (in the case of directory)
# what it should be.

    def exists(my): 
        "( my -- f) Test that current path exists."
        if my._where: return os.path.exists(my())

    def isfile(my): 
        "( my -- f) Discover if current path is a file."
        return os.path.isfile(my())

    def isdir(my): 
        "( my -- f) Discover if current path is directory."
        return os.path.isdir(my())

# The @stat()@ method performs a stat system call on the current
# path. Use contants defined in @stat@ module to interpret data. The
# @size()@ method uses @stat()@ to get the file size in bytes.
    
    def stat(my): 
        "( my -- (...)) Stat current path."
        return os.stat(my())

    def changed(my):
        "(my -- time) Get file modification time."
        return os.stat(my())[stat.ST_MTIME]

    def size(my): 
        "( my -- size) Get size in bytes."
        return os.stat(my())[stat.ST_SIZE]

# Sometimes, you need to know if you have permission to read or write
# a file or path. The @access()@ method returns true if requested
# access is available, false otherwise.

# Use the real uid/gid to test for access to a path.  Note that most
# operations will use the effective uid/gid, therefore this routine can
# be used in a suid/sgid environment to test if the invoking user has the
# specified access to the path.  The mode argument can be F_OK to test
# existence, or the inclusive-OR of R_OK, W_OK, and X_OK.

    def access(my, how): return os.access(my(), how)

# h3. Names

# These methods will return provide useful subsections of the current
# path. @dirname()@ gives the name of the path's parent
# directory. @basename()@ provides the last element of the path, which
# is usually the file or directory name. @justname()@ removes the
# extension from the basename (if there is one). Be careful -- it
# doesn't know what a reasonable extension looks like, it just removes
# everything after the last period in the basename.

# @extension()@ is the opposite of @justname()@, with the same
# caveats. @suffix()@ is @extension()@ for REB-heads.

    def dirname(my): return os.path.dirname(my())

    def basename(my): return os.path.basename(my())

    def justname(my): return os.path.splitext(my.basename())[0]

    def extension(my): return os.path.splitext(my())[-1]
    suffix = extension

# h3. File I/O

# These methods provide convenient access to the contents of
# files. @slurp()@ and @slurplines()@ will read in current file as
# one big chunk or a list of lines, respectively. Best used for smaller
# files that won't throw you into swap...

    def slurp(my):
        if not my.exists(): return None
        return my.open("rb").read()

    def slurplines(my):
        if not my.exists(): return None
        return my.open("rb").readlines()

# @open()@ opens the current file for the appropriate mode and returns
# a Python file object for reading or writing.

    def open(my, mode=None, buffering=None):
        if not my._where: return None
        
        args = [my(),]
        if mode: args.append(mode)
        if buffering: args.append(buffering)
        return open(*args)

# @dump()@ will indiscriminately write out a string (@data@) to the
# current file.

    def dump(my, data):
        f = my.open(mode="wb")

# Seems that I need to buffer my output, based on
# http://support.microsoft.com/default.aspx?scid=kb;en-us;899149

        bsize = 1024 * 1024 # Buffer size.
        s = 0; e = bsize; nd = len(data)
        while s < nd:
            f.write(data[s:e])
            s = e; e += bsize
            
        f.close()

        return my

# h3. Creating Directories

# @mkdir()@ creates current path as directory, building intermediate
# directories as required. If you're on UNIX, you can specify the
# permissions the new dirctories should have. (You can still do it on
# Windows, but it probably won't work...)

    def mkdir(my, mode=None):
        "( my /mode -- my) Make directory with mode, including intermediates."

        if not mode: mode = 0777
        if not my.exists(): os.makedirs(my(), mode=mode)
        return my

# @mkparent()@ creates the parent of current path as directory. Useful
# for when you have a path, but also need the enclosing
# directory. Returns current path object for chaining.

    def mkparent(my):
        parent = my.dirname()
        if parent: path(parent).mkdir(); return my

# h3. Removing Files and Directories

    def unlink(my): 
        if my._where: return os.unlink(my())

    def rmdir(my): 
        return os.rmdir(my())

# h3. Directory Contents

# If you're a staunch traditionalist, you can use the @listdir()@
# method to get the contents of a directory. If the directory doesn't
# exist or is otherwise compromised, an empty list is returned.

# So I can use this in a functional manner, I ignore errors for the
# most part, returning an empty list instead of raising an exception.

    def listdir(my):
        if not my.isdir(): return [] 
        try: x = os.listdir(my()); x.sort(); return x
        except WindowsError: return []

# However, the @files()@ method is much more useful. The @match@ and @filter@
# parameters make it easy to restrict the files returned according to
# specific criteria. @filter@ is very powerful, since you specify a
# function that is given a full path for each file found. Perhaps some
# examples will make things clear:

# |_. Call                              |_. Results                            | 
# | p.files()                           | ["one", "two", "three", "directory"] | 
# | p.files("e")                        | ["one", "three", "directory"]        | 
# | p.files(match=re.compile("e$"))     | ["one", "three"]                     | 
# | p.files(filter=lambda x: x.isdir()) | ["directory",]                       | 

    def files(my, match=None, filter=None): 
        x = my.listdir()

        if match:
            if stringp(match): # String match.
                x = [e for e in x if match in e]
            else: # Regular expression.
                x = [e for e in x if match.search(e)]

        if filter:
            x = [e for e in x if filter(path("%s/%s" % (my(), e)))]

        return x

# Sometimes, just getting a list of the available files isn't enough
# and I need to make sure a directory has the appropriate files in
# it. The @audit()@ method checks to see if certain specifications are
# met. The specifications are a series of lists describing what to
# look for:

# |_. Command |_. Arguments |_. Description |_. Example | 
# | ignore    | _file1, ..._ | Ignores files. | @("ignore", "file1", ...)@ | 
# | files     | _file1, ..._ | Requires files. | @("files", "file1", ...)@ | 
# | frames    | _template, start, end_ | Requires frame range. Template should be set up with an integer expansion. | @("frames", "%04d.tif", 1, 100)@ | 

# The @ignore@ specification can take two kinds of file specifications
# -- a string for a complete filename or a compiled regular
# expression. This allows for cases where the full name of a file may
# not be known.

# A hash is returned listing the files @wanted@, the ones @found@,
# @missing@ and surplus to requirements ( @extra@ ). If a set of
# specifications happens to describe the same file multiple times, it
# will only be checked for once.

    def audit(my, *cases):
        assert my.isdir(), "%s is not a directory." % my()

# First, I assemble a dictionary with everything that I want:

        ignore_words = []; ignore_re = []; wanted = {}; later = None

        for case in cases:
            if "ignore" == case[0]:
                for word in case[1:]:
                    if stringp(word): ignore_words.append(word)
                    else: ignore_re.append(word)
            elif "files" == case[0]:
                for file in case[1:]: wanted[file] = False
            elif "frames" == case[0]:
                (format, start, end) = case[1:]
                for i in range(start, end + 1): wanted[format % i] = False
            elif "later" == case[0]:
                later = case[1]

# Now, I go through all files in the directory, hoping to find them.

        extra = []; laters = []
        for file in my.files():
            if   file in wanted:
                if later and later < path(my, file).changed():
                    laters.append(file)
                wanted[file] = True
            elif file in ignore_words: continue
            elif ignore_re and [x for x in ignore_re if x.match(file)]: continue
            else: extra.append(file)

# Once I've determined the status of everything, I package it all up
# into nice lists:

        found = []; missing = []
        for file, there in wanted.items(): 
            if there: found.append(file)
            else: missing.append(file)

        return {
            "wanted":  wanted.keys(),
            "found":   found, 
            "missing": missing, 
            "extra":   extra,
            "later":   laters,
            }
